/*
 * JavaZOOM : jlgui@javazoom.net http://www.javazoom.net
 * ----------------------------------------------------------------------- This
 * program is free software; you can redistribute it and/or modify it under the
 * terms of the GNU Library General Public License as published by the Free
 * Software Foundation; either version 2 of the License, or (at your option) any
 * later version. This program is distributed in the hope that it will be
 * useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library
 * General Public License for more details. You should have received a copy of
 * the GNU Library General Public License along with this program; if not, write
 * to the Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139,
 * USA.----------------------------------------------------------------------
 */
package de.preamp.clients.java.player;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Control;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.Line;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;

import javazoom.spi.PropertiesContainer;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.tritonus.share.sampled.TAudioFormat;
import org.tritonus.share.sampled.file.TAudioFileFormat;

/**
 * This is the core playback class. It is based on
 * javazoom.jlgui.basicplayer.BasicPlayer
 * 
 * @author André Stannek
 * @since 2010-08-18
 * @version 2011-06-05
 */
public class PreampPlayer implements PreampPlayerOperations, Runnable // NOPMD
{ // NOPMD
    /**
     * 
     */
    public static final int EXTERNAL_BUFFER_SIZE = 1024; // NOPMD

    /**
     * 
     */
    private transient Thread thread = null;

    /**
     * 
     */
    private transient AudioInputStream audioInputStream;

    /**
     * 
     */
    private transient AudioFileFormat audioFileFormat;

    /**
     * 
     */
    private transient SourceDataLine line;

    /**
     * 
     */
    private transient FloatControl gainControl;

    /**
     * 
     */
    private transient FloatControl panControl;

    /**
     * 
     */
    private String mixerName = null;

    /**
     * 
     */
    private int lineBufferSize = -1;

    /**
     * 
     */
    private static final Log LOG = LogFactory.getLog(PreampPlayer.class);

    /**
     * 
     */
    private transient PlayerState status = PlayerState.UNKNOWN;

    /**
     * Listeners to be notified.
     */
    private final transient Collection<PreampPlayerListener> listeners;

    /**
     * 
     */
    private transient int bytesRead;

    /**
     * Constructs a Basic Player.
     */
    public PreampPlayer()
    {
	super();
	listeners = new ArrayList<PreampPlayerListener>();
	reset();
    }

    @Override
    public final void open(final InputStream inputStream)
	    throws UnsupportedAudioFileException, IOException,
	    LineUnavailableException
    {
	if (inputStream != null)
	{
	    LOG.info("open(" + inputStream.toString() + ")");
	    reset();
	    notifyEvent(EventType.OPENING, getEncodedStreamPosition(), -1,
		    inputStream);
	    audioInputStream = AudioSystem.getAudioInputStream(inputStream);
	    audioFileFormat = AudioSystem.getAudioFileFormat(inputStream);
	    final Map<String, Object> properties = initAudioInputStream();
	    for (final PreampPlayerListener listener : listeners)
	    {
		listener.opened(inputStream, properties);
	    }
	}
    }

    @Override
    public final void play() throws PreampPlayerException, // NOPMD
	    LineUnavailableException
    {
	if (status.equals(PlayerState.STOPPED))
	{
	    initAudioInputStream();
	}
	if (status.equals(PlayerState.OPENED))
	{
	    LOG.info("startPlayback called");
	    if (!(thread == null || !thread.isAlive()))
	    {
		LOG.info("WARNING: old thread still running!!");
		int cnt = 0;
		while (!status.equals(PlayerState.OPENED))
		{
		    try
		    {
			if (thread != null)// NOPMD
			{
			    LOG.info("Waiting ... " + cnt);
			    cnt++;
			    Thread.sleep(1000);
			    if (cnt > 2)
			    {
				thread.interrupt();
			    }
			}
		    }
		    catch (final InterruptedException e)
		    {
			throw new PreampPlayerException(
				PreampPlayerException.WAITERROR, e);
		    }
		}
	    }
	    // Open SourceDataLine.
	    try
	    {
		initLine();
	    }
	    catch (final LineUnavailableException e)
	    {
		throw new PreampPlayerException(
			PreampPlayerException.CANNOTINITLINE, e);
	    }
	    LOG.info("Creating new thread");
	    thread = new Thread(this, "BasicPlayer");
	    thread.start();
	    if (line != null)
	    {
		line.start();
		status = PlayerState.PLAYING;
		notifyEvent(EventType.PLAYING, getEncodedStreamPosition(), -1,
			null);
	    }
	}
    }

    @Override
    public final void stop()
    {
	if ((status.equals(PlayerState.PLAYING))
		|| (status.equals(PlayerState.PAUSED)))
	{
	    if (line != null)
	    {
		line.flush();
		line.stop();
	    }
	    status = PlayerState.STOPPED;
	    thread.interrupt();
	    synchronized (audioInputStream)
	    {
		closeStream();
	    }
	    notifyEvent(EventType.STOPPED, getEncodedStreamPosition(), -1, null);
	    LOG.info("stopPlayback() completed");
	}
    }

    @Override
    public final void pause()
    {
	if (line != null && status.equals(PlayerState.PLAYING))
	{
	    line.flush();
	    line.stop();
	    status = PlayerState.PAUSED;
	    LOG.info("pause() completed");
	    notifyEvent(EventType.PAUSED, getEncodedStreamPosition(), -1, null);
	}
    }

    @Override
    public final long seek(final long bytes) throws PreampPlayerException,
	    LineUnavailableException
    {
	// TODO Auto-generated method stub
	return 0;
    }

    @Override
    public final void resume()
    {
	if (line != null && status.equals(PlayerState.PAUSED))
	{
	    line.start();
	    status = PlayerState.PLAYING;
	    LOG.info("resume() completed");
	    notifyEvent(EventType.RESUMED, getEncodedStreamPosition(), -1, null);
	}
    }

    @Override
    public final void setPan(final double fPan) throws PreampPlayerException
    {
	if (hasPanControl())
	{
	    LOG.debug("Pan : " + fPan);
	    panControl.setValue((float) fPan);
	    notifyEvent(EventType.PAN, getEncodedStreamPosition(), fPan, null);
	}
	else
	{
	    throw new PreampPlayerException(
		    PreampPlayerException.PANCONTROLNOTSUPPORTED);
	}
    }

    @Override
    public final void setGain(final double fGain) throws PreampPlayerException
    {
	if (hasGainControl())
	{
	    final double minGainDB = getMinimumGain();
	    final double ampGainDB =
		    ((10.0f / 20.0f) * getMaximumGain()) - getMinimumGain();
	    final double cste = Math.log(10.0) / 20;
	    final double valueDB =
		    minGainDB
			    + (1 / cste)
			    * Math.log(1 + (Math.exp(cste * ampGainDB) - 1)
				    * fGain);
	    LOG.debug("Gain : " + valueDB);
	    gainControl.setValue((float) valueDB);
	    notifyEvent(EventType.GAIN, getEncodedStreamPosition(), fGain, null);
	}
	else
	{
	    throw new PreampPlayerException(
		    PreampPlayerException.GAINCONTROLNOTSUPPORTED);
	}
    }

    /**
     * Add listener to be notified.
     * 
     * @param listener
     *            the new listener
     */
    public final void addBasicPlayerListener(final PreampPlayerListener listener)
    {
	listeners.add(listener);
    }

    /**
     * Remove registered listener.
     * 
     * @param listener
     *            the listener to remove
     */
    public final void removeBasicPlayerListener(
	    final PreampPlayerListener listener)
    {
	listeners.remove(listener);
    }

    /**
     * Set SourceDataLine buffer size. It affects audio latency. (the delay
     * between line.write(data) and real sound). Minimum value should be over
     * 10000 bytes.
     * 
     * @param size
     *            -1 means maximum buffer size available.
     */
    public final void setLineBufferSize(final int size)
    {
	lineBufferSize = size;
    }

    /**
     * Return SourceDataLine buffer size.
     * 
     * @return the buffer size or -1 for maximum buffer size.
     */
    public final int getLineBufferSize()
    {
	return lineBufferSize;
    }

    /**
     * Returns BasicPlayer status.
     * 
     * @return status
     */
    public final PlayerState getStatus()
    {
	return status;
    }

    /**
     * Returns true if Gain control is supported.
     * 
     * @return true if Gain control is supported
     */
    public final boolean hasGainControl()
    {
	// Try to get Gain control again (to support J2SE 1.5)
	if (gainControl == null && line != null
		&& line.isControlSupported(FloatControl.Type.MASTER_GAIN))
	{
	    gainControl =
		    (FloatControl) line
			    .getControl(FloatControl.Type.MASTER_GAIN);
	}
	return gainControl != null;
    }

    /**
     * Returns Gain value.
     * 
     * @return the Gain value
     */
    public final float getGainValue()
    {
	if (hasGainControl())
	{
	    return gainControl.getValue();
	}
	else
	{
	    return 0.0F;
	}
    }

    /**
     * Gets max Gain value.
     * 
     * @return the max Gain value
     */
    public final float getMaximumGain()
    {
	if (hasGainControl())
	{
	    return gainControl.getMaximum();
	}
	else
	{
	    return 0.0F;
	}
    }

    /**
     * Gets min Gain value.
     * 
     * @return the min Gain value
     */
    public final float getMinimumGain()
    {
	if (hasGainControl())
	{
	    return gainControl.getMinimum();
	}
	else
	{
	    return 0.0F;
	}
    }

    /**
     * Returns true if Pan control is supported.
     * 
     * @return true if Pan control is supported
     */
    public final boolean hasPanControl()
    {
	// Try to get Pan control again (to support J2SE 1.5)
	if (panControl == null && line != null
		&& line.isControlSupported(FloatControl.Type.PAN))
	{
	    panControl = (FloatControl) line.getControl(FloatControl.Type.PAN);
	}
	return panControl != null;
    }

    /**
     * Returns Pan precision.
     * 
     * @return the Pan precision
     */
    public final float getPrecision()
    {
	if (hasPanControl())
	{
	    return panControl.getPrecision();
	}
	else
	{
	    return 0.0F;
	}
    }

    /**
     * Returns Pan value.
     * 
     * @return the Pan value
     */
    public final float getPan()
    {
	if (hasPanControl())
	{
	    return panControl.getValue();
	}
	else
	{
	    return 0.0F;
	}
    }

    /**
     * @return a List of Mixers
     */
    public final List<String> getMixers()
    {
	final ArrayList<String> mixers = new ArrayList<String>();
	final Mixer.Info[] mInfos = AudioSystem.getMixerInfo();
	if (mInfos != null)
	{
	    for (int i = 0; i < mInfos.length; i++)
	    {
		final Line.Info lineInfo = new Line.Info(SourceDataLine.class);
		final Mixer mixer = AudioSystem.getMixer(mInfos[i]);
		if (mixer.isLineSupported(lineInfo))
		{
		    mixers.add(mInfos[i].getName());
		}
	    }
	}
	return mixers;
    }

    /**
     * @param name
     *            the name of the mixer to get
     * @return a Mixer
     */
    public final Mixer getMixer(final String name)
    {
	Mixer mixer = null;
	if (name != null)
	{
	    final Mixer.Info[] mInfos = AudioSystem.getMixerInfo();
	    if (mInfos != null)
	    {
		for (int i = 0; i < mInfos.length; i++)
		{
		    if (mInfos[i].getName().equals(name))
		    {
			mixer = AudioSystem.getMixer(mInfos[i]);
			break;
		    }
		}
	    }
	}
	return mixer;
    }

    /**
     * @return the name of the mixer
     */
    public final String getMixerName()
    {
	return mixerName;
    }

    /**
     * @param name
     *            the name of the mixer
     */
    public final void setMixerName(final String name)
    {
	mixerName = name;
    }

    /**
     * 
     */
    private void reset()
    {
	status = PlayerState.UNKNOWN;
	bytesRead = 0;
	if (audioInputStream != null)
	{
	    synchronized (audioInputStream)
	    {
		closeStream();
	    }
	}
	audioInputStream = null; // NOPMD
	audioFileFormat = null; // NOPMD
	if (line != null)
	{
	    line.stop();
	    line.close();
	    line = null; // NOPMD
	}
	gainControl = null; // NOPMD
	panControl = null; // NOPMD
    }

    /**
     * Inits AudioInputStream and AudioFileFormat from the data source.
     * 
     * @return the properties of the audio file
     * @throws LineUnavailableException
     *             if a matching line is not available due to resource
     *             restrictions
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> initAudioInputStream() // NOPMD
	    throws LineUnavailableException
    {
	createLine();
	// Notify listeners with AudioFileFormat properties.
	Map<String, Object> properties;
	if (audioFileFormat instanceof TAudioFileFormat)
	{
	    // Tritonus SPI compliant audio file format.
	    properties = ((TAudioFileFormat) audioFileFormat).properties();
	    // Clone the Map because it is not mutable.
	    properties = new HashMap<String, Object>(properties);
	}
	else
	{
	    properties = new HashMap<String, Object>();
	}
	// Add JavaSound properties.
	if (audioFileFormat.getByteLength() > 0)
	{
	    properties.put("audio.length.bytes",
		    Integer.valueOf(audioFileFormat.getByteLength()));
	}
	if (audioFileFormat.getFrameLength() > 0)
	{
	    properties.put("audio.length.frames",
		    Integer.valueOf(audioFileFormat.getFrameLength()));
	}
	if (audioFileFormat.getType() != null)
	{
	    properties.put("audio.type", audioFileFormat.getType().toString());
	}
	// Audio format.
	final AudioFormat audioFormat = audioFileFormat.getFormat();
	if (audioFormat.getFrameRate() > 0)
	{
	    properties.put("audio.framerate.fps",
		    new Float(audioFormat.getFrameRate()));
	}
	if (audioFormat.getFrameSize() > 0)
	{
	    properties.put("audio.framesize.bytes",
		    Integer.valueOf(audioFormat.getFrameSize()));
	}
	if (audioFormat.getSampleRate() > 0)
	{
	    properties.put("audio.samplerate.hz",
		    new Float(audioFormat.getSampleRate()));
	}
	if (audioFormat.getSampleSizeInBits() > 0)
	{
	    properties.put("audio.samplesize.bits",
		    Integer.valueOf(audioFormat.getSampleSizeInBits()));
	}
	if (audioFormat.getChannels() > 0)
	{
	    properties.put("audio.channels",
		    Integer.valueOf(audioFormat.getChannels()));
	}
	if (audioFormat instanceof TAudioFormat)
	{
	    // Tritonus SPI compliant audio format.
	    final Map<? extends String, ? extends Object> addproperties =
		    ((TAudioFormat) audioFormat).properties();
	    properties.putAll(addproperties);
	}
	// Add SourceDataLine
	properties.put("basicplayer.sourcedataline", line);
	status = PlayerState.OPENED;
	notifyEvent(EventType.OPENED, getEncodedStreamPosition(), -1, null);
	return properties;
    }

    /**
     * Inits Audio ressources from AudioSystem.
     * 
     * @throws LineUnavailableException
     *             if a matching line is not available due to resource
     *             restrictions
     */
    private void initLine() throws LineUnavailableException
    {
	LOG.info("initLine()");
	if (line == null)
	{
	    createLine();
	}
	if (line.isOpen())
	{
	    final AudioFormat lineAudioFormat = line.getFormat();
	    final AudioFormat inputStreamFormat =
		    audioInputStream == null ? null : audioInputStream
			    .getFormat();
	    if (!lineAudioFormat.equals(inputStreamFormat))
	    {
		line.close();
		openLine();
	    }
	}
	else
	{
	    openLine();
	}
    }

    /**
     * Inits a DateLine.<br>
     * We check if the line supports Gain and Pan controls. From the
     * AudioInputStream, i.e. from the sound file, we fetch information about
     * the format of the audio data. These information include the sampling
     * frequency, the number of channels and the size of the samples. There
     * information are needed to ask JavaSound for a suitable output line for
     * this audio file. Furthermore, we have to give JavaSound a hint about how
     * big the internal buffer for the line should be. Here, we say
     * AudioSystem.NOT_SPECIFIED, signaling that we don't care about the exact
     * size. JavaSound will use some default value for the buffer size.
     * 
     * @throws LineUnavailableException
     *             if a matching line is not available due to resource
     *             restrictions
     */
    private void createLine() throws LineUnavailableException
    {
	LOG.info("Create Line");
	if (line == null)
	{
	    final AudioFormat sourceFormat = audioInputStream.getFormat();
	    LOG.info("Create Line : Source format : " + sourceFormat.toString());
	    int nSampleSizeInBits = sourceFormat.getSampleSizeInBits();
	    if (nSampleSizeInBits <= 0)
	    {
		nSampleSizeInBits = 16;
	    }
	    if ((sourceFormat.getEncoding() == AudioFormat.Encoding.ULAW)
		    || (sourceFormat.getEncoding() == AudioFormat.Encoding.ALAW))
	    {
		nSampleSizeInBits = 16;
	    }
	    if (nSampleSizeInBits != 8)
	    {
		nSampleSizeInBits = 16;
	    }
	    final AudioFormat targetFormat =
		    new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
			    sourceFormat.getSampleRate(), nSampleSizeInBits,
			    sourceFormat.getChannels(),
			    sourceFormat.getChannels()
				    * (nSampleSizeInBits / 8),
			    sourceFormat.getSampleRate(), false);
	    LOG.info("Create Line : Target format: " + targetFormat);
	    // Create decoded stream.
	    audioInputStream =
		    AudioSystem.getAudioInputStream(targetFormat,
			    audioInputStream);
	    final AudioFormat audioFormat = audioInputStream.getFormat();
	    final DataLine.Info info =
		    new DataLine.Info(SourceDataLine.class, audioFormat,
			    AudioSystem.NOT_SPECIFIED);
	    final Mixer mixer = getMixer(mixerName);
	    if (mixer == null)
	    {
		line = (SourceDataLine) AudioSystem.getLine(info);
		mixerName = null; // NOPMD
	    }
	    else
	    {
		LOG.info("Mixer : " + mixer.getMixerInfo().toString());
		line = (SourceDataLine) mixer.getLine(info);
	    }
	    LOG.info("Line : " + line.toString());
	    LOG.debug("Line Info : " + line.getLineInfo().toString());
	    LOG.debug("Line AudioFormat: " + line.getFormat().toString());
	}
    }

    /**
     * Opens the line.
     * 
     * @throws LineUnavailableException
     *             if a matching line is not available due to resource
     *             restrictions
     */
    private void openLine() throws LineUnavailableException
    {
	if (line != null)
	{
	    final AudioFormat audioFormat = audioInputStream.getFormat();
	    int buffersize = lineBufferSize;
	    if (buffersize <= 0)
	    {
		buffersize = line.getBufferSize();
	    }
	    line.open(audioFormat, buffersize);
	    LOG.info("Open Line : BufferSize=" + buffersize);
	    /*-- Display supported controls --*/
	    final Control[] controls = line.getControls();
	    for (int p = 0; p < controls.length; p++)
	    {
		LOG.debug("Controls : " + controls[p].toString());
	    }
	    /*-- Is Gain Control supported ? --*/
	    if (line.isControlSupported(FloatControl.Type.MASTER_GAIN))
	    { // NOPMD
	      // TODO
	      // gainControl =
	      // (FloatControl) line
	      // .getControl(FloatControl.Type.MASTER_GAIN);
	      // LOG.info("Master Gain Control : [" + gainControl.getMinimum()
	      // + "," + gainControl.getMaximum() + "] "
	      // + gainControl.getPrecision());
	    }
	    /*-- Is Pan control supported ? --*/
	    if (line.isControlSupported(FloatControl.Type.PAN))
	    { // NOPMD
	      // panContr =
	      // (FloatControl) line.getControl(FloatControl.Type.P);
	      // LOG.info("Pan Control : [" + panControl.getMinimum() +,"
	      // + panControl.getMaximum() + "
	      // + panControl.getPrecision());
	    }
	}
    }

    /**
     * 
     */
    private void closeStream()
    {
	// Close stream.
	try
	{
	    if (audioInputStream != null)
	    {
		audioInputStream.close();
		LOG.info("Stream closed");
	    }
	}
	catch (final IOException e)
	{
	    LOG.info("Cannot close stream", e);
	}
    }

    /**
     * Main loop. Player Status == STOPPED || SEEKING => End of Thread + Freeing
     * Audio Ressources.<br>
     * Player Status == PLAYING => Audio stream data sent to Audio line.<br>
     * Player Status == PAUSED => Waiting for another status.
     */
    @SuppressWarnings("unchecked")
    public final void run() // NOPMD
    {
	LOG.info("Thread Running");
	int nBytesRead = 0;
	final byte[] abData = new byte[EXTERNAL_BUFFER_SIZE];
	// Lock stream while playing.
	synchronized (audioInputStream)
	{
	    try
	    {
		nBytesRead = audioInputStream.read(abData, 0, abData.length);
		// Main play/pause loop.
		while ((nBytesRead != -1)
			&& !status.equals(PlayerState.STOPPED)
			&& !status.equals(PlayerState.SEEKING)
			&& !status.equals(PlayerState.UNKNOWN))
		{
		    if (status.equals(PlayerState.PLAYING))
		    {
			// Play.
			nBytesRead =
				audioInputStream.read(abData, 0, abData.length);
			bytesRead += nBytesRead;
			final byte[] pcm = new byte[nBytesRead];
			System.arraycopy(abData, 0, pcm, 0, nBytesRead);
			if (line.available() >= line.getBufferSize())
			{
			    LOG.debug("Underrun : " + line.available() + "/"
				    + line.getBufferSize());
			}
			line.write(abData, 0, nBytesRead);
			// Compute position in bytes in encoded stream.
			final int nEncodedBytes = getEncodedStreamPosition();
			// Notify listeners
			for (final PreampPlayerListener listener : listeners)
			{
			    if (audioInputStream instanceof PropertiesContainer)
			    {
				// Pass audio parameters such as instant
				// bitrate, ...
				final Map<String, Object> properties =
					((PropertiesContainer) audioInputStream)
						.properties();
				listener.progress(nEncodedBytes,
					line.getMicrosecondPosition(), pcm,
					properties);
			    }
			    else
			    {
				listener.progress(nEncodedBytes,
					line.getMicrosecondPosition(), pcm,
					new HashMap<String, Object>());
			    }
			}
		    }
		}
	    }
	    catch (final IOException e)
	    {
		LOG.error("Thread cannot run()", e);
		status = PlayerState.STOPPED;
		notifyEvent(EventType.STOPPED, getEncodedStreamPosition(), -1,
			null);
	    }
	    // Free audio resources.
	    if (line != null)
	    {
		line.drain();
		line.stop();
		line.close();
		line = null; // NOPMD
	    }
	    // Notification of "End Of Media"
	    notifyEvent(EventType.EOM, getEncodedStreamPosition(), -1, null);
	    // Close stream.
	    closeStream();
	}
	status = PlayerState.STOPPED;
	notifyEvent(EventType.STOPPED, getEncodedStreamPosition(), -1, null);
	LOG.info("Thread completed");
    }

    /**
     * @return the position in the stream
     */
    private int getEncodedStreamPosition()
    {
	return bytesRead;
    }

    /**
     * Notify listeners about a BasicPlayerEvent.
     * 
     * @param type
     *            event code.
     * @param position
     *            in the stream when the event occurs
     * @param value
     *            the value
     * @param description
     *            the description
     */
    private void notifyEvent(final EventType type, final int position,
	    final double value, final Object description)
    {
	final PreampPlayerEventLauncher trigger =
		new PreampPlayerEventLauncher(type, position, value,
			description, new ArrayList<PreampPlayerListener>(
				listeners), this);
	trigger.start();
    }
}
